A practical set of notes and lessons for building event‑driven systems with MassTransit on Azure Service Bus (ASB). The focus is the mindset shift from command / point‑to‑point messaging to event / publish‑subscribe messaging, and the handful of platform concepts — sessions, partitioning, outbox, inbox, batching, versioning — that you need to get right along the way.
Examples are drawn from a document management application — documents, folders, versions, tags, shares, and the background services that react to them (search indexing, preview/thumbnail generation, storage accounting, audit logging, notifications) — but the ideas transfer to any domain.
TL;DR — Command messaging vs Event messaging
| Concern | Command / point‑to‑point | Event / publish‑subscribe |
| Topology | Each message targets a single recipient and is handled by one processor. | One event is published to a topic; each interested service subscribes independently. |
| Producer knowledge | Producer must know every recipient and send one message per recipient. | Publisher is (theoretically) unaware of any subscriber. |
| Naming | Imperative commands ("do X"). | Past‑tense events ("X happened"). |
| Coupling | Adding a consumer means changing the producer. | Adding a consumer means adding a subscription — the producer is untouched. |
| Ordering | Usually via locks / single‑threaded processing. | ASB Sessions (FIFO per SessionId), when the consumer opts in. |
| Reliability | Whatever the platform provides. | Transactional outbox (publish side) + inbox (consumer side). |
The two are complementary — commands are still the right tool when you genuinely need to tell one specific service to do one specific thing. But cross‑service reactions to state changes almost always belong in events.
1. Point‑to‑Point (command) vs Publish/Subscribe (event)
In command / point‑to‑point messaging, each message targets a single recipient and is handled by a single processor. This forces the producer to be aware of every processor and to send a separate, individually‑addressed message to each one.
For example, when a document is deleted, several background services must react. A command‑based producer sends one message per service:
// The producer must name and message every service that needs to react:
bus.Send(new RemoveFromSearchIndex(documentId), searchServiceEndpoint);
bus.Send(new DeletePreviews(documentId), previewServiceEndpoint);
bus.Send(new ReleaseStorageQuota(documentId), storageServiceEndpoint);
bus.Send(new RevokeShares(documentId), sharingServiceEndpoint);
bus.Send(new WriteAuditEntry(documentId), auditServiceEndpoint);
// … one message per recipient; add a new service and the producer must change.
The producer enumerates the recipients and knows each destination. Introduce a new service that also needs to react to a deletion — say, a data‑retention service — and you must go back and modify the producer.
Events invert this. A single event is published, describing what happened, and every service that cares subscribes independently and takes its own action:
// The producer states a fact and knows nothing about who reacts:
await publishEndpoint.Publish(new DocumentDeleted(workspaceId, documentId));
Now search removes the document from its index, the preview service deletes thumbnails, storage reclaims quota, sharing revokes links, and audit records the deletion — each in its own consumer, each free to change independently. When the retention service is added later, you add a subscriber; the producer never grows a new branch.
Migration tip
You can move an existing point‑to‑point message onto ASB by swapping the transport with a 1:1 command‑to‑message mapping — but that misses the point. The value is in the paradigm shift:
- command → event (past tense, "what happened"), and
- single producer / single processor → single publisher / many independent subscribers.
Redesign at the event boundary; don't just re‑platform the command.
2. Event names
Keep event names descriptive and in the past tense — they answer "what happened?". The name should describe exactly what occurred, with no hidden conditions.
Good: DocumentUploaded, DocumentMoved, DocumentShared, FolderDeleted, VersionCreated.
Compare with command‑style names, which are imperative and directed at a specific recipient: RemoveFromSearchIndex, RebuildPreview, RecalculateStorageQuota. The grammatical difference is the tell — a command tells one recipient to do something; an event states that something happened and lets subscribers decide.
Avoid a redundant Event or Message suffix (DocumentUploadedEvent) — the type usually already lives in an events namespace, so the suffix is noise. Pick one convention and hold to it.
3. The publisher is (theoretically) unaware of any particular subscriber
A publisher should not know about — or write code in favour of — any specific subscriber, and should never gate a publish on a condition that only one subscriber cares about.
Anti‑pattern:
if (tagsChanged)
{
await publishEndpoint.Publish(new DocumentUpdated(documentId));
}
This is wrong on two counts. First, the if is written in favour of one subscriber (say, the search indexer) that only cares when tags changed. Second, the name DocumentUpdated promises the event fires whenever a document is updated — the guard silently breaks that promise, so any other subscriber that legitimately needs all updates (the preview service, the audit log) never hears about them.
The correct pattern is to publish unconditionally and carry the "what changed" flags on the event, so each subscriber decides for itself:
public record DocumentUpdated(
Guid WorkspaceId,
Guid DocumentId,
bool HasTitleChanged,
bool HasContentChanged,
bool HasTagsChanged,
bool HasFolderChanged
// … one flag per meaningful change …);
The publisher fires DocumentUpdated on every update; the search indexer checks HasTitleChanged || HasContentChanged || HasTagsChanged, the preview service checks HasContentChanged, and each ignores the rest. Nobody is privileged in the contract.
When a guard is justified: if an event only makes sense when the document was moved between folders, then it isn't DocumentUpdated — it's a different event. Name it DocumentMoved and publish that. The rule: the name must fully describe when the event fires; no hidden conditions.
4. Versioning
MassTransit serialises messages as JSON, so adding a new property to an existing event is backward‑compatible: existing consumers ignore the new field, and messages published by older code deserialize with the new property defaulted. This should be your first resort.
Changing or removing an existing property (rename, retype, drop) is a breaking change — it breaks existing consumers and any in‑flight messages. When you must, define a new versioned event (DocumentUpdatedV2, and so on) and run both in parallel until every consumer has migrated, then retire the old one.
Design events to be additive from the start: prefer widening (new optional fields) over reshaping.
5. Batching
You don't need to batch on the publisher side — publish one event per item, at its natural granularity (one DocumentUpdated per document). The consumer can opt into batching, and MassTransit will hand it a Batch of accumulated messages, letting it collapse many events into a single downstream operation — one bulk search‑index update instead of one call per document.
A consumer opts in by configuring BatchOptions and consuming Batch:
// In the consumer definition — accumulate up to 50 messages, or flush after 1 second, whichever comes first:
consumerConfigurator.Options<BatchOptions>(o =>
o.SetMessageLimit(50).SetTimeLimit(TimeSpan.FromSeconds(1)));
// In the search indexer — one bulk index call per workspace instead of one per document:
public async Task Consume(ConsumeContext<Batch<DocumentUpdated>> context)
{
foreach (var group in context.Message.GroupBy(m => m.Message.WorkspaceId))
{
var documentIds = group.Select(m => m.Message.DocumentId).Distinct().ToList();
// … re-index all of this workspace's changed documents in a single bulk request …
}
}
Two things make batching pay off: a downstream that is cheaper in bulk (a bulk search‑index API, a batched storage call), and a grouping key to fold the batch on (workspace, folder, document) before doing the work.
6. Sessions and ordering
Azure Service Bus does not guarantee messages are processed in order by default. Two rapid edits to the same document can be delivered — and processed — out of order, so a stale version could overwrite a newer one in the search index. To get ordering you use sessions: messages that share a SessionId are delivered FIFO, and the session is locked to a single consumer while a message is being processed.
Getting ordered processing right is a two‑sided contract:
-
The publisher sets the
SessionId. Choose a stable key that defines the ordering scope. For "process edits to the same document in order", the natural key is the document ID; if instead you need all activity within a workspace ordered, use the workspace ID. All messages sharing that key are then processed in order relative to one another. (Messages with different session keys still run concurrently — you get ordering within a key, parallelism across keys.) -
The consumer enables sessions. The receive queue is owned and configured by the consumer, so requiring sessions is a consumer‑side switch (
RequiresSession). Setting aSessionIdon publish has no ordering effect unless the consuming queue requires sessions — without it, consumers run as competing consumers and order is not guaranteed.
Two more things worth knowing:
-
SessionIdis Azure Service Bus‑specific. If you also run on RabbitMQ or the in‑memory transport (common for local development and tests), they ignore it — don't rely on session ordering there. -
SessionIddoubles as the partition key. When ASB partitioning is enabled, theSessionIdis also used to assign a message to a partition, co‑locating related messages for throughput and locality. Mind the cardinality of the key: using the document ID spreads load well; using a single busy workspace ID for a huge workspace can create a hot partition. Pick a key with enough spread for the ordering scope you actually need.
Enable sessions only where ordering genuinely matters (same‑document edits, version sequences); it serialises processing per key and costs throughput. Where order doesn't matter — deleting unrelated documents, indexing independent files — competing consumers scale better.
7. Direct vs Outbox publishing
Events can be published directly to the bus, or through a transactional outbox.
-
Outbox — the event is written to an outbox table on the same database transaction as your business change, so the publish and the state change both commit or both roll back. Save the new document version and its
DocumentUpdatedevent together, and you can never end up with a version saved but never indexed (a lost event), nor an event announcing a version that the transaction rolled back (a phantom event). A background dispatcher then relays the outbox rows to the broker, retrying until they land. - Direct — publishes straight to the bus. Simpler and lower‑latency, but there is no atomicity guarantee: if the process dies between the DB commit and the publish (or the transaction aborts after a publish), the two are inconsistent, and you need a compensating plan.
Prefer the outbox whenever your handler already runs inside a database transaction — the correctness win is almost always worth it. A good pattern is to depend on a small publishing abstraction so call sites don't care whether they got the direct or outbox implementation; the host decides via configuration.
MassTransit provides a transactional outbox out of the box (with EF Core and other persistence integrations); see the MassTransit documentation for the setup specific to your ORM.
Publish inside the same unit of work, and carry context in headers. Publish the event within the same transaction that made the change, so the outbox can bind them. And propagate ambient context — the workspace/tenant, the acting user, and a correlation/trace ID — into the message headers at publish time, rather than assuming it flows implicitly. Background dispatch and async hops can lose thread‑local/async‑local context, so stamping it explicitly is what lets the audit consumer record who deleted the document and which workspace it belonged to.
8. Inbox
Without an inbox, a single event can be processed more than once — Azure Service Bus delivery is at‑least‑once (redelivery after a transient failure, a lock timeout, or a consumer crash mid‑processing). In many cases that's fine, especially when the consumer is idempotent — reprocessing the same event converges on the same result. Re‑indexing a document is idempotent: upserting the same document into the search index twice leaves it in the same state.
When duplicate processing would cause a problem, enable the inbox. Sending a "a document was shared with you" email is the classic non‑idempotent case — processing DocumentShared twice sends two emails. The inbox deduplicates by message ID to give effectively‑once processing, and — paired with the outbox — makes the consumer's own write‑then‑publish atomic (its database write and any events it emits commit together).
Default to designing consumers to be idempotent; reach for the inbox only where duplicates are genuinely harmful. Idempotency is cheaper and more robust than relying on exactly‑once semantics that distributed systems can't truly guarantee.
9. Delivery guarantees, retry & dead‑lettering
Delivery is at‑least‑once, so failure handling is part of the design, not an afterthought. A robust consumer endpoint layers three mechanisms:
- Immediate retry — a few quick retries for transient blips (a momentary timeout, a deadlock). Cheap, and resolves the majority of failures.
- Delayed / scheduled redelivery — for a message that keeps failing — say, a preview service whose rendering dependency is temporarily down — push it back with exponential backoff (seconds → minutes → hours) instead of hammering it, giving the dependency time to recover. This frees the consumer to make progress on other messages meanwhile.
- Dead‑letter queue (DLQ) — once retries and redeliveries are exhausted (a corrupt file the preview service simply can't render), the message lands in a DLQ where it can be inspected, alerted on, and replayed after a fix — rather than being silently dropped or blocking the queue forever.
The practical contract: make consumers idempotent, let retry/redelivery absorb transients, and rely on the DLQ for the truly broken. Monitor DLQ depth — a growing DLQ is one of the clearest signals that something downstream is wrong.
10. Rolling out new event flows safely
Introducing an event flow across services is a coordination problem. A few practices keep it safe:
- Gate publisher and subscriber independently (e.g. with feature flags), per workspace where possible. You can start publishing an event before any consumer exists, and stand up a consumer that stays dormant until switched on — so producer and consumer don't have to ship in lockstep.
- Consumers before producers, logically. A consumer that receives an event type it doesn't yet handle should degrade gracefully, not crash. Deploy the ability to receive ahead of the flood of messages.
- Prove behavioural parity when an event flow replaces existing logic — run both the old and new paths and assert identical outcomes before cutting over.
- Watch the seams. New subscriptions, DLQ depth, consumer lag, and duplicate‑processing counters are the things to have on a dashboard from day one.
Quick checklist for adding a new event
- Name it in the past tense — "what happened", no hidden conditions. Model it as an immutable record keyed by the identifiers a subscriber needs (workspace / document IDs).
- Carry the "what changed" flags on the event; never gate the publish in favour of one subscriber.
-
Set a
SessionId(document or workspace key) if ordering may ever matter — and enable sessions on the consumer only where it actually does. - Prefer the outbox whenever the handler runs in a transaction, so the publish is atomic with the state change. Carry workspace / user / correlation IDs in headers.
-
Make the consumer idempotent; batch with
BatchOptionswhere the downstream is cheaper in bulk; add the inbox only if duplicates are harmful. -
Add fields additively; introduce a
V2event only for breaking changes, and run both until consumers migrate. - Roll out behind a flag, gating producer and consumer independently, and monitor the seams (lag, DLQ, duplicates).
Further reading
- MassTransit documentation — transports, consumers, sagas, the transactional outbox, batching, and retry/redelivery configuration.
- Azure Service Bus documentation — topics & subscriptions, sessions, partitioning, dead‑letter queues, and duplicate detection.
- Enterprise Integration Patterns (Hohpe & Woolf) — the vocabulary behind most of the above (Publish‑Subscribe Channel, Message Router, Idempotent Receiver, Guaranteed Delivery).
